﻿using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Globalization;
using System.Linq;
using System.Net;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using System.Web;
using System.Web.UI;
using BrandonHaynes.Membership.Factors.PromptControls;
using DotNetNuke.Common.Lists;
using DotNetNuke.Entities.Portals;
using DotNetNuke.Entities.Profile;
using DotNetNuke.Entities.Users;
using DotNetNuke.Security.Membership;
using DotNetNuke.Services.Log.EventLog;
using DotNetNuke.Common;
using DotNetNuke.Entities.Host;

namespace BrandonHaynes.Membership.Factors
	{
	/// <summary>
	/// A class for using a one-time password generated via a YubiKey (www.yubico.com)
	/// </summary>
	public class YubiKeyFactor : IAuthenticationFactor
		{
		private const string defaultUri = "https://api.yubico.com/wsapi/verify";

		/// <summary>
		/// A set of attributes as specified in the web.config 
		/// </summary>
		protected IDictionary<string, string> Attributes { get; private set; }

		/// <summary>
		/// Indicates if a YubiKey should be associated with an account when first used, or if keys will
		/// be associated manually (and externally).  Note that this flag may be activated, accounts associated,
		/// and then the flag cleared.
		/// 
		/// If external association is used, a SHA-1 HMAC is used on a string of the 
		/// format id={userId}&otp={YubiKey Id} using the key specified in the factor configuration.
		/// 
		/// The public method GenerateSignature(userId, yubiKeyId) may be used for this purpose.
		/// </summary>
		protected bool AssociateOnFirstUse { get; set; }

		/// <summary>
		/// Gets or sets the profile property used to persist the associated YubiKey id.  If none is specified,
		/// the profile property "YubiKey" is used.  Note that this profile property will be created upon 
		/// instantiation, if it does not already exist.
		/// </summary>
		protected string ProfilePropertyName { get; set; }

		private string ApiId { get; set; }
		private byte[] ApiKey { get; set; }

		public YubiKeyFactor(IDictionary<string, string> attributes)
			{ 
			Attributes = attributes;

			if (!Attributes.ContainsKey("id"))
				throw new ArgumentException(@"An valid Yubico application identifier is required on your factor configuration (i.e. id=""0000"").  See http://yubico.com for details.", "attributes");
			else if (!Attributes.ContainsKey("key"))
				throw new ArgumentException(@"An valid Yubico API key is required on your factor configuration (i.e. key=""F3zX..."").  See http://yubico.com for details.", "attributes");

			AssociateOnFirstUse = bool.Parse(Attributes.GetValueOrDefault("associateOnFirstUse", "true"));
			ProfilePropertyName = Attributes.GetValueOrDefault("profileProperty", Name);
			ApiId = Attributes.GetValueOrDefault("id", string.Empty);
			ApiKey = Convert.FromBase64String(Attributes.GetValueOrDefault("key", string.Empty));
			
			Attributes.Remove("id");
			Attributes.Remove("key");

			EnsureProfilePropertyExists();
			}

		#region IAuthenticationFactor Members

		public string Name { get { return "YubiKey"; } }

		public virtual void Authenticate(UserInfo user, Credential credential)
			{
			var onetimePassword = credential[Name];

			if (string.IsNullOrEmpty(ApiId) || ApiKey.Length == 0)
				{
				new EventLogController().AddLog(Name, @"An valid Yubico application key or identifier is required on your factor configuration (i.e. id=""0000"" key=""F3zX..."").  See http://yubico.com for details.  No YubiKey will authenticate until this is corrected.", PortalController.GetCurrentPortalSettings(), user.UserID, EventLogController.EventLogType.LOGIN_FAILURE);
				credential.Status = UserLoginStatus.LOGIN_USERLOCKEDOUT;
				}
			else if (string.IsNullOrEmpty(onetimePassword))
				{
				credential.IncompleteFactors.Add(this);
				credential.Status = UserLoginStatus.LOGIN_SUCCESS;
				}
			else if (!VerifyKeyAssociation(user, onetimePassword))
				credential.Status = UserLoginStatus.LOGIN_FAILURE;
			else if (!VerifyOnetimePassword(user, onetimePassword))
				credential.Status = UserLoginStatus.LOGIN_FAILURE;
			else
				{
				AssociateKey(user, onetimePassword);
				credential.Status = UserLoginStatus.LOGIN_SUCCESS;
				}
			}

		/// <summary>
		/// Retrieves a control used to collect credential information (in this case a prompt for the one-time password)
		/// </summary>
		public virtual Control PromptControl
			{ get { return new PasswordPrompt(Name, Attributes); } }

		#endregion

		/// <summary>
		/// Given a user and onetimePassword, extracts the YubiKey ID (first twelve octets) and validates
		/// it against the profile-persisted id.  
		/// 
		/// If AssociateOnFirstUse is set and the profile-persisted id is empty, returns true.  
		/// Note that this exposes a vector (outside clearing of the user profile property).
		/// We rely on DotNetNuke to protect this data from malicious manipulation.
		/// </summary>
		/// <param name="user">The user to verify</param>
		/// <param name="onetimePassword">The YubiKey one-time password</param>
		/// <returns></returns>
		private bool VerifyKeyAssociation(UserInfo user, string onetimePassword)
			{
			var associatedHmac = user.Profile.GetPropertyValue(ProfilePropertyName);
			var keyId = onetimePassword.Length >= 12 ? onetimePassword.Substring(0, 12) : string.Empty;
			var presentedHmac = GenerateSignature(user.UserID.ToString(CultureInfo.InvariantCulture), keyId);

			return presentedHmac.Equals(associatedHmac) || (AssociateOnFirstUse && string.IsNullOrEmpty(associatedHmac));
			}

		/// <summary>
		/// Associates a YubiKey with a user if:
		/// 
		///		1) A key has not already been associated (or has been administratively cleared), and
		///		2) AssociateOnFirstUse is set
		///		
		/// Otherwise the association must be updated manually by an administrator (see this.AssociateOnFirstUse
		/// for more details)
		/// </summary>
		/// <param name="user"></param>
		/// <param name="onetimePassword"></param>
		public void AssociateKey(UserInfo user, string onetimePassword)
			{
			if (AssociateOnFirstUse && string.IsNullOrEmpty(user.Profile.GetPropertyValue(ProfilePropertyName)))
				{
				var keyId = onetimePassword.Substring(0, 12);
				var hmac = GenerateSignature(user.UserID.ToString(CultureInfo.InvariantCulture), keyId);
				user.Profile.SetProfileProperty(ProfilePropertyName, hmac);
				}
			}

		/// <summary>
		/// Transmits a one-time password to a validator and verifies signatures
		/// </summary>
		/// <param name="user">The user attempting to authenticate</param>
		/// <param name="onetimePassword">The one-time password to be used for subsequent authentication</param>
		private bool VerifyOnetimePassword(UserInfo user, string onetimePassword)
			{
			var builder = new UriBuilder(Attributes.GetValueOrDefault("verifyUri", defaultUri)) 
				{ Query = string.Format("id={0}&otp={1}&h={2}", ApiId, HttpUtility.UrlEncode(onetimePassword), HttpUtility.UrlEncode(GenerateSignature(ApiId, onetimePassword))) };

			// Connect to our client, validate the signature, and check for success
			var client = new WebClient();
			var response = client.DownloadString(builder.Uri);
			var data = HttpUtility.ParseQueryString(Regex.Replace(response, @"\s+", "&"));
			var signatureValid = IsHMACValid(data, "h");
			var success = signatureValid && data["status"].Equals("OK", StringComparison.OrdinalIgnoreCase);

			// Log two failure modes; one for signature and one for everything else.  Response is dumped in both cases.
			if(!signatureValid)
				new EventLogController().AddLog(Name, string.Format(@"Invalid signature ""{0}"" for response ""{1}""", data["h"], response), PortalController.GetCurrentPortalSettings(), user.UserID, EventLogController.EventLogType.LOGIN_FAILURE);
			else if (!success)
				new EventLogController().AddLog(Name, response, PortalController.GetCurrentPortalSettings(), user.UserID, EventLogController.EventLogType.LOGIN_FAILURE);

			return success;
			}

		public string GenerateSignature(string id, string key)
			{ return GenerateSignature(new Dictionary<string, string>(2) { { "id", id }, {"otp", key } }); }

		/// <summary>
		/// 1. Convert a dictionary to &-separated pairs of =-separated name/values.  
		/// 2. Name-value pairs are sorted alphabetically
		/// 3. Submit for SHA1 HMAC generation.
		/// </summary>
		/// <returns>A SHA1 HMAC of the provided dictionary</returns>
		private string GenerateSignature(IDictionary<string, string> values)
			{	
			var plaintext = string.Join("&",
				values
					.OrderBy(pair => pair.Key)
					.Select(pair => string.Concat(pair.Key.Trim(), '=', pair.Value.Trim())).ToArray());

			return GenerateHMAC_SHA1_Base64Encoded(plaintext, ApiKey, Encoding.UTF8);
			}

		/// <summary>
		/// Generates a SHA1 HMAC and Base64 encodes it
		/// </summary>
		private string GenerateHMAC_SHA1_Base64Encoded(string plaintext, byte[] key, Encoding encoding)
			{
			byte[] buffer = encoding.GetBytes(plaintext);

			var hmacProvider = new HMACSHA1(ApiKey);

			return Convert.ToBase64String(hmacProvider.ComputeHash(buffer));
			}

		private bool IsHMACValid(IDictionary<string, string> values, string hashKey)
			{ 
			// Okay we're filtering out our HMAC here because it's not part of signature validation.
			// We do that by removing the pair from the dictionary and then converting back (so that
			// the original dictionary is unchanged).  This is inefficient and a little sloppy.
			return values[hashKey] == HttpUtility.UrlDecode(GenerateSignature(values
				.Where(pair => !hashKey.Equals(pair.Key))
				.ToDictionary(pair => pair.Key, pair => pair.Value))); }

		/// <summary>
		/// Determines if a given HMAC is equal to the HMAC generated for the provided name/value pairs.
		/// 
		/// Note that this method filters any entry defined at hashKey, as this is not part of the digest.
		/// </summary>
		private bool IsHMACValid(NameValueCollection values, string hashKey)
			{ return IsHMACValid(values.AllKeys
				.Where(key => !string.IsNullOrEmpty(key))
				.ToDictionary(key => key, key => values[key]), hashKey); }

		/// <summary>
		/// Creates the specified ProfilePropertyName for YubiKey id, if it does not already exist
		/// </summary>
		private void EnsureProfilePropertyExists()
			{
			if (ProfileController.GetPropertyDefinitionByName(-1, ProfilePropertyName) == null)
				ProfileController.AddPropertyDefinition(
					new ProfilePropertyDefinition(-1)
					{
						DataType = new ListController().GetListEntryInfo("DataType", "Text").EntryID,
						Length = 24,
						DefaultValue = string.Empty,
						PropertyCategory = "Name",
						PropertyName = ProfilePropertyName,
						Visibility = UserVisibilityMode.AdminOnly,
						Visible = false
					});
			}
		}
	}
